Skip to content

fix(checkout): count repo failures and disjoin them into the exit code - #903

Merged
laynepenney merged 1 commit into
devfrom
fix/checkout-exit-code-on-repo-failures
Aug 21, 2026
Merged

fix(checkout): count repo failures and disjoin them into the exit code#903
laynepenney merged 1 commit into
devfrom
fix/checkout-exit-code-on-repo-failures

Conversation

@laynepenney

@laynepenney laynepenney commented Aug 21, 2026

Copy link
Copy Markdown
Member

What

gr checkout reported each per-repo failure to the terminal and incremented no counter, so a run in which every repo failed printed Switched 0/N repos to <branch> and exited 0. The printed ratio is something a caller has to read and interpret; the exit code is the only failure signal a script or CI job sees, and it reported the batch as done.

This adopts cli/repo_iter::for_each_repo — which is what supplies an error count at all — and makes a nonzero exit disjoin the per-repo failures.

Behavior change

gr checkout now exits nonzero when one or more repos fail to check out. A repo where the target branch simply does not exist is a skip, not a failure, and still exits 0.

This is a deliberate change to a shipped exit code, called out here so it is not discovered downstream. Nothing documented the previous behavior; it fell out of an unconditional Ok(()). A script that depends on the old exit 0 is a script that is currently green over failed checkouts.

Two changes that fall out of the adoption

  • The cloned-check in for_each_repo. It called path_exists on the repo directory, while its own docstring promised to skip repos that "aren't cloned". RepoInfo::exists tests for .git, which is what cloned means. The two diverge on a directory that exists and is not a clone. Aligning the code with the docstring preserves checkout's existing behavior exactly, rather than quietly reclassifying an existing skip as an error. The mismatch had never been exposed because the helper had no callers.
  • for_each_repo_path is removed. It had no callers and gains none here.

Plan-doc correction

docs/PLAN-p2-maintainability.md carried a step saying that if clippy flagged unused repo_iter imports, the pub visibility from cli/mod.rs should suppress it. That step is struck. An unused private item warns and an unused pub item does not — so following the step as written left the module with zero callers and nothing anywhere going red for as long as it existed. A warning is a detector; silencing one to reach a clean build is not a fix, and writing the suppression down as a step made it the default for whoever came next.

The same doc's scoped-out row said most commands "accumulate custom state that doesn't fit the simple Success/Skipped/Error enum". That is largely right, and it is now measured rather than assumed: of the nine command files that hand-roll these counters, two do not iterate repos at all, one iterates pull requests asynchronously, and three propagate ? out of the loop — which the closure's return type cannot express. checkout was the one clean fit.

Tests

Two tests pin both sides of one property. The discriminator is an absent .git versus a present but unopenable one:

  • .git removed → not cloned → skip → exits 0 (the existing test_checkout_skips_non_git_repo)
  • .git present but not a valid repository → error → exits nonzero (new)

These are separate tests with their own fixtures, and those fixtures differ in more than the discriminating property: one corrupts a single repo and the other both, one creates a branch first, and they target different branches. The claim is about which property discriminates — verified by mutation — not about the fixtures being otherwise identical.

Without both sides, a witness asserting only the failure case could pass while the skip path had silently become an error as well. If the first case ever goes red the pair stops discriminating and the second proves nothing; that reasoning is recorded in the test itself.

A third test pins that an absent branch remains a skip, so the change cannot over-correct into failing runs that are legitimately no-ops.

Full test suite green.

Why anyhow::bail! and not the cli/outcome.rs vocabulary

CliOutcomeError exists and this change deliberately does not use it. Two separate questions sit behind that:

The exit code. exit_code_for_error falls back to 1 for a plain error, which is the right code here. EXIT_REFUSED (2) means the command understood the request and declined to act; a repo that failed to check out is a failure to perform, not a refusal. Giving both the same code would make an upstream failure indistinguishable from a deliberate one, and the distinction is the reason that module exists.

The rendering. error_was_reported defines itself as whether the command already printed the diagnostic that explains this failure. The per-repo failures are printed inside the loop, and each explains one repo. The error returned here is the aggregate, and its count is printed nowhere else: Switched 3/5 repos to <branch> reports successes and does not distinguish a repo that failed from one that was skipped. So the summary line main renders is the only place the failure count appears, and marking this error already-reported would delete it.

Worth stating plainly, since it is the same shape as the defect above: that rendering currently comes from unwrap_or(false) rather than from anything this code declares. The behavior is right and it is inherited from a default rather than asserted. Adding a constructor to express it would put new surface in outcome.rs with exactly one caller, which is the trade this change is not worth making.

Premium boundary

Premium boundary: grip is OSS because this is workspace orchestration and CLI process semantics — no identity, no organization state, no entitlement behavior.

Ref #886 — this implements rule 3 at one call site and does not close the contract issue.

@laynepenney laynepenney left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ATLAS r2 BLOCK, merge gate, bound to exact head f99f1edac857037b0e697218b284de7c0478a740.

Frozen artifact bind, independently re-derived:

  • range.patch RAW: 7549911bfa5ef0c8ae0eb66e7e8b90212b05cb8d25c7038e7c8570b00eab1a12
  • metadata.fuller.txt RAW: c37f087535227342c7560f5c7c3ad816542ebcad13079213ca490096e8e21384
  • pr-title.txt NORM: 673164cdbfcbf00c029e3ca60adf7c8aab7e73092d13c3da1cf6c384a7e2da44
  • pr-body.md NORM: 0555cf5061d4c9a0579af3a3ab039c3f1881adea9c97949c7751d500a90b220b

BLOCKERS

  1. cargo fmt --check fails on two files changed by this head:

    • src/cli/commands/checkout.rs: import ordering
    • tests/test_checkout.rs: the long assertion needs the multiline form required by rustfmt

    I ran the same command on exact base 1dd7474c822c993345bd3a5f7111b1806b383881 as a control. Base has one unrelated pre-existing failure in tests/manifest_input_warnings.rs; the two failures above are introduced by this range.

  2. The frozen commit message and PR body say the two test fixtures "differ only in whether .git exists." Literally, the complete fixtures also differ in repository count/state and branch. The tests do make the intended distinction, so narrow the claim to what the evidence establishes: absent .git skips successfully, while present-but-unopenable .git is counted as an error and returns nonzero.

CALLS ON THE THREE REQUESTED SEAMS

  1. PASS: RepoInfo::exists() is the right cloned predicate here. It matches the helper contract and checkout behavior before adopting the helper. The majority use of directory existence by unrelated callers does not override this command-specific invariant. The helper currently has no other callers.

  2. PASS: plain anyhow::bail! is correct. This is failure to perform, exit 1, not understood-and-refused, exit 2. The per-repo messages explain individual failures; the aggregate error count is not otherwise rendered, so treating the aggregate as unreported is also correct.

  3. PASS functionally: the pair discriminates. Replacing repo.exists() with directory path_exists killed only the non-git skip witness. Removing the error-count exit disjunction killed only the all-repositories-failing witness. The wording still needs the narrowing above.

RAN:

  • all four hash derivations
  • live remote head/base checks with present and absent controls
  • ahead/behind: 1/0
  • regenerated range patch and metadata, byte-identical to frozen artifacts
  • git diff --check: exit 0
  • focused checkout suite: 12/12
  • full cargo test: exit 0
  • both targeted mutants described above
  • cargo fmt --check at head: exit 1
  • cargo fmt --check at base control: exit 1 only for the unrelated pre-existing file

READ:

  • every line of the four frozen artifacts
  • live PR title/body against the frozen platform text
  • surrounding RepoInfo::exists, outcome classification, main renderer, checkout, helper, tests, and release caller

Format the changed files and correct the frozen prose, then amend and re-freeze. The new head and body need a fresh r2.

gr checkout reported every per-repo failure to the terminal and
incremented no counter, so a run in which every repo failed printed
"Switched 0/N repos to <branch>" and exited 0. The printed ratio is
something a caller has to read and interpret; the exit code is the only
failure signal a script sees, and it reported the batch as done.

Adopt cli/repo_iter::for_each_repo -- which is what gives this command an
error count at all -- and make a nonzero exit disjoin the per-repo
failures.

Two changes fall out of the adoption:

- for_each_repo's cloned-check called path_exists on the repo directory
  while its own docstring promised to skip repos that "aren't cloned".
  RepoInfo::exists tests for .git, which is what cloned means; the two
  diverge on a directory that exists and is not a clone. Aligning the
  code with the docstring preserves checkout's existing behavior exactly
  rather than reclassifying a skip as an error.
- for_each_repo_path had no callers and gains none here, so it is
  removed rather than left relying on pub visibility to keep its own
  dead-code warning quiet.

The plan step prescribing that suppression is struck in the same change.
An unused private item warns and an unused pub item does not, so
following the step as written left the module with zero callers and
nothing anywhere going red for as long as it existed. A warning is a
detector; silencing one to reach a clean build is not a fix.

Tests pin both sides of one property. The discriminator is an ABSENT
.git versus a PRESENT BUT UNOPENABLE one: absent is a skip and still
exits 0, present but unopenable is an error and exits nonzero. Without
both sides, a witness asserting only the failure case could pass while
the skip path had silently become an error too.

These are two separate tests with their own fixtures, and those fixtures
differ in more than the discriminating property -- one corrupts a single
repo and the other both, one creates a branch first, and they target
different branches. The claim is about which property discriminates, not
about the fixtures being otherwise identical.
@laynepenney
laynepenney force-pushed the fix/checkout-exit-code-on-repo-failures branch from f99f1ed to a9263bf Compare August 21, 2026 12:07

@laynepenney laynepenney left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ATLAS r2 APPROVE, merge gate, bound to exact head a9263bf1bc12532bad1c1199946255b259f42315.

Frozen v3 binds, independently re-derived:

  • range.patch RAW: 7cec0eae17ee6877787254c9eb3356686f7c3b9917fde9dafa7e505b2bc5b383
  • metadata.fuller.txt RAW: 9df6c90c895aca999dab9b66d2808299626a6b1443f9d9b3f5f7262bce988c3a
  • pr-title.txt NORM: 673164cdbfcbf00c029e3ca60adf7c8aab7e73092d13c3da1cf6c384a7e2da44
  • pr-body.md NORM: 626e7a09afadbe3d331cf942eb593b4a3d6252fdadc7364bd57652647aeb4ef6

Both prior blockers are closed.

  1. Formatting: the two PR-changed files pass rustfmt --check --edition 2021. I ran cargo fmt --check at both exact base 1dd7474c822c993345bd3a5f7111b1806b383881 and this head. Both return nonzero only for the same pre-existing tests/manifest_input_warnings.rs:281 delta. The PR introduces no formatting delta and correctly leaves that unrelated file outside this range.

  2. Difference-set claim: the commit message and live PR body now distinguish the property under test from fixture identity. They name absent .git versus present-but-unopenable .git, state that the fixtures are not otherwise identical, and enumerate the other material differences. The retired differ only class is absent from the frozen and live surfaces.

The three requested calls remain:

  1. RepoInfo::exists(): PASS.
  2. Plain anyhow::bail!: PASS.
  3. Test pair: PASS and mutation-discriminating.

RAN at exact v3 head:

  • all four frozen hash derivations
  • live remote base/head plus present and absent controls
  • ahead/behind: 1/0
  • regenerated range patch and fuller metadata, byte-identical to frozen artifacts
  • live title/body normalized round-trip, both equal frozen
  • retired-phrase negative scan plus replacement-text positive control
  • git diff --check: exit 0
  • changed-file rustfmt --check --edition 2021: exit 0
  • base/head cargo fmt --check differential control described above
  • focused checkout suite: 12/12
  • full cargo test --quiet: exit 0
  • cloned-predicate mutant: skip witness failed, all-failing witness passed
  • exit-disjunction mutant: all-failing witness failed, skip witness passed
  • restored isolated tree: clean, exact head retained

READ:

  • every line of the five-file v3 range, fuller metadata, title, and body
  • complete v2 to v3 range, metadata, and platform-text delta
  • the repaired formatting locations and complete difference-set wording
  • surrounding cloned predicate, outcome classification, rendering, checkout, helper, tests, and release caller

Scope: this APPROVE closes r2 for the merge gate at the exact head above. Stromus's separate r1 covers the public-push gate.

@laynepenney
laynepenney merged commit f6f178c into dev Aug 21, 2026
1 check passed
@laynepenney
laynepenney deleted the fix/checkout-exit-code-on-repo-failures branch August 21, 2026 12:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant